Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,850 changes: 1,584 additions & 266 deletions apps/cli/src/application/useCases/DiffArtefactsUseCase.spec.ts

Large diffs are not rendered by default.

93 changes: 40 additions & 53 deletions apps/cli/src/application/useCases/DiffArtefactsUseCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,18 @@ import {
ArtefactDiff,
} from '../../domain/useCases/IDiffArtefactsUseCase';
import { IPackmindGateway } from '../../domain/repositories/IPackmindGateway';
import {
ArtifactType,
ChangeProposalType,
FileModification,
} from '@packmind/types';
import { diffLines } from 'diff';
import * as fs from 'fs/promises';
import * as path from 'path';
import { stripFrontmatter } from '../utils/stripFrontmatter';

type DiffableFile = FileModification & {
content: string;
artifactType: ArtifactType;
artifactName: string;
};

const ARTIFACT_TYPE_TO_CHANGE_TYPE: Record<ArtifactType, ChangeProposalType> = {
command: ChangeProposalType.updateCommandDescription,
standard: ChangeProposalType.updateStandardDescription,
skill: ChangeProposalType.updateSkillDescription,
};
import { FileModification } from '@packmind/types';
import { DiffableFile } from './diffStrategies/DiffableFile';
import { IDiffStrategy } from './diffStrategies/IDiffStrategy';
import { CommandDiffStrategy } from './diffStrategies/CommandDiffStrategy';
import { SkillDiffStrategy } from './diffStrategies/SkillDiffStrategy';

export class DiffArtefactsUseCase implements IDiffArtefactsUseCase {
constructor(private readonly packmindGateway: IPackmindGateway) {}
private readonly strategies: IDiffStrategy[];

constructor(private readonly packmindGateway: IPackmindGateway) {
this.strategies = [new CommandDiffStrategy(), new SkillDiffStrategy()];
}

public async execute(
command: IDiffArtefactsCommand,
Expand Down Expand Up @@ -60,47 +48,46 @@ export class DiffArtefactsUseCase implements IDiffArtefactsUseCase {
file.content !== undefined,
);

const prefixedSkillFolders = this.prefixSkillFolders(
response.skillFolders,
command.relativePath,
);

const diffs: ArtefactDiff[] = [];

for (const file of diffableFiles) {
const fullPath = path.join(baseDirectory, file.path);

const localContent = await this.tryReadFile(fullPath);
if (localContent === null) {
continue;
const strategy = this.strategies.find((s) => s.supports(file));
if (strategy) {
const fileDiffs = await strategy.diff(file, baseDirectory, {
skillFolders: prefixedSkillFolders,
});
diffs.push(...fileDiffs);
}
}

const serverBody = stripFrontmatter(file.content);
const localBody = stripFrontmatter(localContent);
const changes = diffLines(serverBody, localBody);
const hasDifferences = changes.some(
(change) => change.added || change.removed,
);

if (hasDifferences) {
diffs.push({
filePath: file.path,
type: ARTIFACT_TYPE_TO_CHANGE_TYPE[file.artifactType],
payload: {
oldValue: serverBody,
newValue: localBody,
},
artifactName: file.artifactName,
artifactType: file.artifactType,
artifactId: file.artifactId,
spaceId: file.spaceId,
});
for (const strategy of this.strategies) {
if (strategy.diffNewFiles) {
const newFileDiffs = await strategy.diffNewFiles(
prefixedSkillFolders,
diffableFiles,
baseDirectory,
);
diffs.push(...newFileDiffs);
}
}

return diffs;
}

private async tryReadFile(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, 'utf-8');
} catch {
return null;
}
private prefixSkillFolders(
skillFolders: string[],
relativePath?: string,
): string[] {
if (!relativePath) return skillFolders;
let normalized = relativePath;
while (normalized.startsWith('/')) normalized = normalized.slice(1);
while (normalized.endsWith('/')) normalized = normalized.slice(0, -1);
if (!normalized) return skillFolders;
return skillFolders.map((folder) => `${normalized}/${folder}`);
}
}
48 changes: 41 additions & 7 deletions apps/cli/src/application/useCases/SubmitDiffsUseCase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,11 +206,14 @@ describe('SubmitDiffsUseCase', () => {
expect(result.submitted).toBe(0);
});

it('skips with reason "Only commands are supported"', async () => {
it('skips with reason "Only commands and skills are supported"', async () => {
const result = await useCase.execute({ groupedDiffs: [standardGroup] });

expect(result.skipped).toEqual([
{ name: 'My Standard', reason: 'Only commands are supported' },
{
name: 'My Standard',
reason: 'Only commands and skills are supported',
},
]);
});

Expand All @@ -236,12 +239,40 @@ describe('SubmitDiffsUseCase', () => {
},
];

it('skips with reason "Only commands are supported"', async () => {
beforeEach(() => {
mockGateway.changeProposals.batchCreateChangeProposals.mockResolvedValue(
batchResponse(1),
);
});

it('returns created count from batch response', async () => {
const result = await useCase.execute({ groupedDiffs: [skillGroup] });

expect(result.skipped).toEqual([
{ name: 'My Skill', reason: 'Only commands are supported' },
]);
expect(result.submitted).toBe(1);
});

it('sends skill diff with correct type and payload', async () => {
await useCase.execute({ groupedDiffs: [skillGroup] });

expect(
mockGateway.changeProposals.batchCreateChangeProposals,
).toHaveBeenCalledWith({
spaceId: 'spc-skl',
proposals: [
{
type: ChangeProposalType.updateSkillDescription,
artefactId: 'art-skl',
payload: { oldValue: 'old', newValue: 'new' },
captureMode: ChangeProposalCaptureMode.commit,
},
],
});
});

it('returns empty skipped array', async () => {
const result = await useCase.execute({ groupedDiffs: [skillGroup] });

expect(result.skipped).toEqual([]);
});
});

Expand Down Expand Up @@ -454,7 +485,10 @@ describe('SubmitDiffsUseCase', () => {
});

expect(result.skipped).toEqual([
{ name: 'A Standard', reason: 'Only commands are supported' },
{
name: 'A Standard',
reason: 'Only commands and skills are supported',
},
{ name: 'No Meta Command', reason: 'Missing artifact metadata' },
]);
});
Expand Down
12 changes: 7 additions & 5 deletions apps/cli/src/application/useCases/SubmitDiffsUseCase.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeProposalCaptureMode, ChangeProposalType } from '@packmind/types';
import { ChangeProposalCaptureMode } from '@packmind/types';

import {
ISubmitDiffsUseCase,
Expand All @@ -8,6 +8,8 @@ import {
import { IPackmindGateway } from '../../domain/repositories/IPackmindGateway';
import { ArtefactDiff } from '../../domain/useCases/IDiffArtefactsUseCase';

const SUPPORTED_ARTIFACT_TYPES = new Set(['command', 'skill']);

type ValidDiff = ArtefactDiff & { artifactId: string; spaceId: string };

export class SubmitDiffsUseCase implements ISubmitDiffsUseCase {
Expand All @@ -24,10 +26,10 @@ export class SubmitDiffsUseCase implements ISubmitDiffsUseCase {
continue;
}

if (firstDiff.artifactType !== 'command') {
if (!SUPPORTED_ARTIFACT_TYPES.has(firstDiff.artifactType)) {
skipped.push({
name: firstDiff.artifactName,
reason: 'Only commands are supported',
reason: 'Only commands and skills are supported',
});
continue;
}
Expand Down Expand Up @@ -60,9 +62,9 @@ export class SubmitDiffsUseCase implements ISubmitDiffsUseCase {
await this.packmindGateway.changeProposals.batchCreateChangeProposals({
spaceId,
proposals: diffs.map((diff) => ({
type: ChangeProposalType.updateCommandDescription,
type: diff.type,
artefactId: diff.artifactId,
payload: diff.payload as { oldValue: string; newValue: string },
payload: diff.payload,
captureMode: ChangeProposalCaptureMode.commit,
})),
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { ChangeProposalType } from '@packmind/types';
import { diffLines } from 'diff';
import * as fs from 'fs/promises';
import * as path from 'path';
import { ArtefactDiff } from '../../../domain/useCases/IDiffArtefactsUseCase';
import { stripFrontmatter } from '../../utils/stripFrontmatter';
import { IDiffStrategy } from './IDiffStrategy';
import { DiffableFile } from './DiffableFile';

export class CommandDiffStrategy implements IDiffStrategy {
supports(file: DiffableFile): boolean {
return file.artifactType === 'command';
}

async diff(
file: DiffableFile,
baseDirectory: string,
): Promise<ArtefactDiff[]> {
const fullPath = path.join(baseDirectory, file.path);
let localContent: string;
try {
localContent = await fs.readFile(fullPath, 'utf-8');
} catch {
return [];
}

const serverBody = stripFrontmatter(file.content);
const localBody = stripFrontmatter(localContent);
const changes = diffLines(serverBody, localBody);
const hasDifferences = changes.some(
(change) => change.added || change.removed,
);

if (!hasDifferences) {
return [];
}

return [
{
filePath: file.path,
type: ChangeProposalType.updateCommandDescription,
payload: {
oldValue: serverBody,
newValue: localBody,
},
artifactName: file.artifactName,
artifactType: file.artifactType,
artifactId: file.artifactId,
spaceId: file.spaceId,
},
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ArtifactType, FileModification } from '@packmind/types';

export type DiffableFile = FileModification & {
content: string;
artifactType: ArtifactType;
artifactName: string;
};
22 changes: 22 additions & 0 deletions apps/cli/src/application/useCases/diffStrategies/IDiffStrategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ArtefactDiff } from '../../../domain/useCases/IDiffArtefactsUseCase';
import { DiffableFile } from './DiffableFile';

export type DiffContext = {
skillFolders: string[];
};

export interface IDiffStrategy {
supports(file: DiffableFile): boolean;

diff(
file: DiffableFile,
baseDirectory: string,
context?: DiffContext,
): Promise<ArtefactDiff[]>;

diffNewFiles?(
folders: string[],
serverFiles: DiffableFile[],
baseDirectory: string,
): Promise<ArtefactDiff[]>;
}
Loading