Skip to content

Commit 28669aa

Browse files
committed
Add git-style diff view for CloudFormation change sets
- Replace property-by-property arrow notation with unified diff format - Use BeforeContext/AfterContext to show complete resource diffs - Display changes with +/- prefixes in diff code blocks - Add inline recreation warnings on properties that require replacement - Fix Tags display by using full context instead of individual Details - Consistent formatting across Add, Modify, and Remove actions
1 parent c650dce commit 28669aa

3 files changed

Lines changed: 354 additions & 91 deletions

File tree

__tests__/changeset-formatter.test.ts

Lines changed: 192 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,16 @@ describe('Change Set Formatter', () => {
426426
ResourceType: 'AWS::DynamoDB::Table',
427427
Replacement: 'True',
428428
Scope: ['Properties'],
429+
BeforeContext: JSON.stringify({
430+
Properties: {
431+
BillingMode: 'PROVISIONED'
432+
}
433+
}),
434+
AfterContext: JSON.stringify({
435+
Properties: {
436+
BillingMode: 'PAY_PER_REQUEST'
437+
}
438+
}),
429439
Details: [
430440
{
431441
Target: {
@@ -458,9 +468,189 @@ describe('Change Set Formatter', () => {
458468
)
459469
expect(markdown).toContain('**Physical ID:** `my-table-123`')
460470
expect(markdown).toContain('⚠️ **This resource will be replaced**')
461-
expect(markdown).toContain(
462-
'**BillingMode:** `PROVISIONED` → `PAY_PER_REQUEST`'
471+
expect(markdown).toContain('```diff')
472+
expect(markdown).toContain('⚠️ Requires recreation: Always')
473+
})
474+
475+
test('displays AfterContext for Add actions in console output', () => {
476+
const changesSummary = JSON.stringify({
477+
changes: [
478+
{
479+
Type: 'Resource',
480+
ResourceChange: {
481+
Action: 'Add',
482+
LogicalResourceId: 'NewBucket',
483+
ResourceType: 'AWS::S3::Bucket',
484+
AfterContext:
485+
'{"BucketName":"my-bucket","Versioning":{"Status":"Enabled"}}'
486+
}
487+
}
488+
],
489+
totalChanges: 1,
490+
truncated: false
491+
})
492+
493+
displayChangeSet(changesSummary, 1, true)
494+
495+
expect(core.info).toHaveBeenCalledWith(
496+
expect.stringContaining('Properties:')
497+
)
498+
expect(core.info).toHaveBeenCalledWith(
499+
expect.stringContaining('BucketName')
463500
)
501+
})
502+
503+
test('displays BeforeContext for Remove actions in console output', () => {
504+
const changesSummary = JSON.stringify({
505+
changes: [
506+
{
507+
Type: 'Resource',
508+
ResourceChange: {
509+
Action: 'Remove',
510+
LogicalResourceId: 'OldBucket',
511+
ResourceType: 'AWS::S3::Bucket',
512+
BeforeContext: '{"BucketName":"old-bucket"}'
513+
}
514+
}
515+
],
516+
totalChanges: 1,
517+
truncated: false
518+
})
519+
520+
displayChangeSet(changesSummary, 1, true)
521+
522+
expect(core.info).toHaveBeenCalledWith(
523+
expect.stringContaining('Properties:')
524+
)
525+
expect(core.info).toHaveBeenCalledWith(
526+
expect.stringContaining('BucketName')
527+
)
528+
})
529+
530+
test('handles invalid JSON in AfterContext gracefully', () => {
531+
const changesSummary = JSON.stringify({
532+
changes: [
533+
{
534+
Type: 'Resource',
535+
ResourceChange: {
536+
Action: 'Add',
537+
LogicalResourceId: 'NewResource',
538+
ResourceType: 'AWS::Custom::Resource',
539+
AfterContext: 'invalid-json{'
540+
}
541+
}
542+
],
543+
totalChanges: 1,
544+
truncated: false
545+
})
546+
547+
displayChangeSet(changesSummary, 1, true)
548+
549+
expect(core.info).toHaveBeenCalledWith(
550+
expect.stringContaining('invalid-json{')
551+
)
552+
})
553+
554+
test('handles invalid JSON in BeforeContext gracefully', () => {
555+
const changesSummary = JSON.stringify({
556+
changes: [
557+
{
558+
Type: 'Resource',
559+
ResourceChange: {
560+
Action: 'Remove',
561+
LogicalResourceId: 'OldResource',
562+
ResourceType: 'AWS::Custom::Resource',
563+
BeforeContext: 'invalid-json{'
564+
}
565+
}
566+
],
567+
totalChanges: 1,
568+
truncated: false
569+
})
570+
571+
displayChangeSet(changesSummary, 1, true)
572+
573+
expect(core.info).toHaveBeenCalledWith(
574+
expect.stringContaining('invalid-json{')
575+
)
576+
})
577+
578+
test('generates diff view for resources with BeforeContext/AfterContext', () => {
579+
const changesSummary = JSON.stringify({
580+
changes: [
581+
{
582+
Type: 'Resource',
583+
ResourceChange: {
584+
Action: 'Modify',
585+
LogicalResourceId: 'MyTopic',
586+
ResourceType: 'AWS::SNS::Topic',
587+
Replacement: 'False',
588+
BeforeContext: JSON.stringify({
589+
Properties: {
590+
DisplayName: 'old-name',
591+
Tags: [{ Key: 'Env', Value: 'dev' }]
592+
}
593+
}),
594+
AfterContext: JSON.stringify({
595+
Properties: {
596+
DisplayName: 'new-name',
597+
Tags: [{ Key: 'Env', Value: 'prod' }]
598+
}
599+
})
600+
}
601+
}
602+
],
603+
totalChanges: 1,
604+
truncated: false
605+
})
606+
607+
const markdown = generateChangeSetMarkdown(changesSummary)
608+
609+
expect(markdown).toContain('```diff')
610+
expect(markdown).toContain('-')
611+
expect(markdown).toContain('+')
612+
})
613+
614+
test('shows recreation warnings in diff view', () => {
615+
const changesSummary = JSON.stringify({
616+
changes: [
617+
{
618+
Type: 'Resource',
619+
ResourceChange: {
620+
Action: 'Modify',
621+
LogicalResourceId: 'MyParam',
622+
ResourceType: 'AWS::SSM::Parameter',
623+
Replacement: 'True',
624+
BeforeContext: JSON.stringify({
625+
Properties: {
626+
Name: '/old/path',
627+
Value: 'old'
628+
}
629+
}),
630+
AfterContext: JSON.stringify({
631+
Properties: {
632+
Name: '/new/path',
633+
Value: 'new'
634+
}
635+
}),
636+
Details: [
637+
{
638+
Target: {
639+
Name: 'Name',
640+
RequiresRecreation: 'Always'
641+
}
642+
}
643+
]
644+
}
645+
}
646+
],
647+
totalChanges: 1,
648+
truncated: false
649+
})
650+
651+
const markdown = generateChangeSetMarkdown(changesSummary)
652+
653+
expect(markdown).toContain('```diff')
464654
expect(markdown).toContain('⚠️ Requires recreation: Always')
465655
})
466656
})

dist/index.js

Lines changed: 76 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -59733,6 +59733,62 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
5973359733
exports.displayChangeSet = displayChangeSet;
5973459734
exports.generateChangeSetMarkdown = generateChangeSetMarkdown;
5973559735
const core = __importStar(__nccwpck_require__(7484));
59736+
/**
59737+
* Generate a git-style diff view of JSON objects with recreation warnings
59738+
*/
59739+
function generateJsonDiff(before, after, details) {
59740+
const beforeJson = before ? JSON.stringify(before, null, 2) : '{}';
59741+
const afterJson = after ? JSON.stringify(after, null, 2) : '{}';
59742+
if (beforeJson === afterJson) {
59743+
return '```json\n' + beforeJson + '\n```\n';
59744+
}
59745+
// Build map of properties that require recreation
59746+
const recreationMap = new Map();
59747+
if (details) {
59748+
for (const detail of details) {
59749+
const target = detail.Target;
59750+
if ((target === null || target === void 0 ? void 0 : target.Name) &&
59751+
target.RequiresRecreation &&
59752+
target.RequiresRecreation !== 'Never') {
59753+
recreationMap.set(target.Name, target.RequiresRecreation);
59754+
}
59755+
}
59756+
}
59757+
const beforeLines = beforeJson.split('\n');
59758+
const afterLines = afterJson.split('\n');
59759+
const diff = [];
59760+
let i = 0;
59761+
let j = 0;
59762+
while (i < beforeLines.length || j < afterLines.length) {
59763+
const beforeLine = beforeLines[i];
59764+
const afterLine = afterLines[j];
59765+
if (beforeLine === afterLine) {
59766+
diff.push(' ' + beforeLine);
59767+
i++;
59768+
j++;
59769+
}
59770+
else if (i < beforeLines.length && !afterLines.includes(beforeLines[i])) {
59771+
diff.push('-' + beforeLine);
59772+
i++;
59773+
}
59774+
else if (j < afterLines.length) {
59775+
let line = '+' + afterLine;
59776+
// Check if this line contains a property that requires recreation
59777+
for (const [propName, recreationType] of recreationMap) {
59778+
if (afterLine.includes(`"${propName}"`)) {
59779+
line += ` ⚠️ Requires recreation: ${recreationType}`;
59780+
break;
59781+
}
59782+
}
59783+
diff.push(line);
59784+
j++;
59785+
}
59786+
else {
59787+
i++;
59788+
}
59789+
}
59790+
return '```diff\n' + diff.join('\n') + '\n```\n';
59791+
}
5973659792
/**
5973759793
* ANSI color codes
5973859794
*/
@@ -60044,52 +60100,29 @@ function generateChangeSetMarkdown(changesSummary) {
6004460100
else if (rc.Action === 'Modify' && rc.Replacement === 'Conditional') {
6004560101
markdown += `⚠️ **May require replacement**\n\n`;
6004660102
}
60047-
// Property changes
60048-
if (rc.Details && rc.Details.length > 0) {
60049-
markdown += '**Property Changes:**\n\n';
60050-
for (const detail of rc.Details) {
60051-
const target = detail.Target;
60052-
if (!target)
60053-
continue;
60054-
const propName = target.Name || target.Attribute || 'Unknown';
60055-
if (target.BeforeValue && target.AfterValue) {
60056-
markdown += `- **${propName}:** \`${target.BeforeValue}\` → \`${target.AfterValue}\`\n`;
60057-
}
60058-
else if (target.AfterValue) {
60059-
markdown += `- **${propName}:** (added) → \`${target.AfterValue}\`\n`;
60060-
}
60061-
else if (target.BeforeValue) {
60062-
markdown += `- **${propName}:** \`${target.BeforeValue}\` → (removed)\n`;
60063-
}
60064-
if (target.RequiresRecreation &&
60065-
target.RequiresRecreation !== 'Never') {
60066-
markdown += ` - ⚠️ Requires recreation: ${target.RequiresRecreation}\n`;
60067-
}
60068-
}
60069-
markdown += '\n';
60070-
}
60071-
// AfterContext for Add actions
60072-
if (rc.Action === 'Add' && rc.AfterContext) {
60103+
// Show diff view using BeforeContext/AfterContext when available
60104+
if (rc.BeforeContext || rc.AfterContext) {
6007360105
try {
60074-
const afterProps = JSON.parse(rc.AfterContext);
60075-
markdown += '\n**Properties:**\n```json\n';
60076-
markdown += JSON.stringify(afterProps, null, 2);
60077-
markdown += '\n```\n';
60106+
const before = rc.BeforeContext
60107+
? JSON.parse(rc.BeforeContext)
60108+
: undefined;
60109+
const after = rc.AfterContext
60110+
? JSON.parse(rc.AfterContext)
60111+
: undefined;
60112+
markdown += generateJsonDiff(before, after, rc.Details);
6007860113
}
6007960114
catch (_c) {
60080-
// Skip if can't parse
60081-
}
60082-
}
60083-
// BeforeContext for Remove actions
60084-
if (rc.Action === 'Remove' && rc.BeforeContext) {
60085-
try {
60086-
const beforeProps = JSON.parse(rc.BeforeContext);
60087-
markdown += '\n**Properties:**\n```json\n';
60088-
markdown += JSON.stringify(beforeProps, null, 2);
60089-
markdown += '\n```\n';
60090-
}
60091-
catch (_d) {
60092-
// Skip if can't parse
60115+
// If parsing fails, fall back to showing raw JSON
60116+
if (rc.AfterContext) {
60117+
markdown += '\n**Properties:**\n```json\n';
60118+
markdown += rc.AfterContext;
60119+
markdown += '\n```\n';
60120+
}
60121+
else if (rc.BeforeContext) {
60122+
markdown += '\n**Properties:**\n```json\n';
60123+
markdown += rc.BeforeContext;
60124+
markdown += '\n```\n';
60125+
}
6009360126
}
6009460127
}
6009560128
markdown += '\n</details>\n\n';

0 commit comments

Comments
 (0)