Skip to content

Commit b83866a

Browse files
committed
Add report
1 parent 52fe413 commit b83866a

6 files changed

Lines changed: 359 additions & 118 deletions

File tree

.github/workflows/convert-proto.yml

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on:
77
description: 'OpenSearch version. Leave empty to fetch latest.'
88
required: false
99
type: string
10+
repository_dispatch:
11+
types: [spec-updated]
12+
1013
jobs:
1114
auto-proto-convert:
1215
runs-on: ubuntu-latest
@@ -29,7 +32,7 @@ jobs:
2932
- name: Download Release Assets
3033
uses: robinraju/release-downloader@v1
3134
with:
32-
repository: 'opensearch-project/opensearch-api-specification'
35+
repository: 'lucy66hw/opensearch-api-specification'
3336
latest: true
3437
fileName: 'opensearch-openapi.yaml'
3538
tag: 'main-latest'
@@ -119,6 +122,20 @@ jobs:
119122
- name: Post Process Protobuf
120123
run: npm run postprocessing
121124

125+
- name: Generate Merge Report
126+
id: merge_report
127+
run: |
128+
npm run backward-compat -- --dry-run --report /tmp/merge-report.md || true
129+
if [ -f /tmp/merge-report.md ]; then
130+
# Escape for GitHub Actions multiline output
131+
REPORT=$(cat /tmp/merge-report.md)
132+
echo "report<<EOF" >> $GITHUB_OUTPUT
133+
echo "$REPORT" >> $GITHUB_OUTPUT
134+
echo "EOF" >> $GITHUB_OUTPUT
135+
else
136+
echo "report=No changes detected." >> $GITHUB_OUTPUT
137+
fi
138+
122139
- name: Configure Git User
123140
run: |
124141
git config --global user.name "github-actions[bot]"
@@ -152,3 +169,7 @@ jobs:
152169
153170
**OpenSearch Version**: ${{ steps.get_opensearch_version.outputs.version }}
154171
**API Spec Commit**: ${{ steps.get_commit.outputs.latest_commit }}
172+
173+
---
174+
175+
${{ steps.merge_report.outputs.report }}

tools/proto-convert/src/postprocessing/BackwardCompatibleWriter.ts

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,19 @@
1-
import { existsSync } from 'fs';
1+
import { existsSync, writeFileSync } from 'fs';
22
import { Command, Option } from '@commander-js/extra-typings';
3-
import {
4-
ProtoMessage,
5-
ProtoEnum,
6-
BackwardCompatibilityError
7-
} from './types';
3+
import { ProtoMessage, ProtoEnum, BackwardCompatibilityError } from './types';
84
import { parseProtoFile } from './parser';
95
import { mergeMessage, mergeEnum } from './CompatibilityMerger';
106
import { writeProtoFile, CUSTOM_MESSAGE_NAMES, CUSTOM_ENUM_NAMES } from './writer';
7+
import { MergeReporter } from './MergeReporter';
118
import logger from '../utils/logger';
129

1310
export class BackwardCompatibleWriter {
1411
private existingMessages: ProtoMessage[];
1512
private existingEnums: ProtoEnum[];
1613
private incomingMessageMap: Map<string, ProtoMessage> = new Map();
1714
private incomingEnumMap: Map<string, ProtoEnum> = new Map();
18-
private errors: string[] = [];
1915
private outputPath: string;
16+
private reporter: MergeReporter = new MergeReporter();
2017

2118
constructor(existingPath: string, incomingPaths: string[], outputPath: string) {
2219
this.outputPath = outputPath;
@@ -46,7 +43,7 @@ export class BackwardCompatibleWriter {
4643
}
4744
}
4845

49-
process(): void {
46+
process(dryRun: boolean = false): void {
5047
const finalMessages: ProtoMessage[] = [];
5148
const finalEnums: ProtoEnum[] = [];
5249

@@ -59,7 +56,7 @@ export class BackwardCompatibleWriter {
5956

6057
const incomingMsg = this.incomingMessageMap.get(existingMsg.name);
6158
if (incomingMsg) {
62-
finalMessages.push(mergeMessage(existingMsg, incomingMsg, this.errors));
59+
finalMessages.push(mergeMessage(existingMsg, incomingMsg, this.reporter));
6360
this.incomingMessageMap.delete(existingMsg.name);
6461
} else {
6562
finalMessages.push(existingMsg);
@@ -75,7 +72,7 @@ export class BackwardCompatibleWriter {
7572

7673
const incomingEnum = this.incomingEnumMap.get(existingEnum.name);
7774
if (incomingEnum) {
78-
finalEnums.push(mergeEnum(existingEnum, incomingEnum));
75+
finalEnums.push(mergeEnum(existingEnum, incomingEnum, this.reporter));
7976
this.incomingEnumMap.delete(existingEnum.name);
8077
} else {
8178
finalEnums.push(existingEnum);
@@ -96,20 +93,39 @@ export class BackwardCompatibleWriter {
9693
}
9794
}
9895

99-
// Check for errors before writing
100-
if (this.errors.length > 0) {
101-
logger.error('Backward compatibility errors:');
102-
for (const error of this.errors) {
103-
logger.error(` ${error}`);
96+
// Check for backward incompatible changes
97+
if (this.reporter.hasIncompatibleChanges()) {
98+
const errors = this.reporter.getIncompatibleChanges();
99+
logger.error('Backward incompatible changes detected:');
100+
for (const err of errors) {
101+
logger.error(` ${err.messageName}.${err.fieldName}: ${err.existingType}${err.incomingType}`);
104102
}
105-
throw new BackwardCompatibilityError(
106-
`Found ${this.errors.length} backward compatibility violation(s).`
107-
);
103+
104+
if (!dryRun) {
105+
// In real run mode, throw error and don't write
106+
throw new BackwardCompatibilityError(
107+
`Found ${errors.length} backward incompatible change(s). Proto file not updated.`
108+
);
109+
}
110+
// In dry-run mode, continue (report will show the errors)
111+
logger.info(`Dry run: ${this.outputPath} would NOT be updated due to incompatible changes`);
112+
return;
108113
}
109114

110-
// Write output using shared function
111-
writeProtoFile(finalMessages, finalEnums, this.outputPath);
112-
logger.info(`Updated: ${this.outputPath}`);
115+
// Write output using shared function (skip if dry-run)
116+
if (dryRun) {
117+
logger.info(`Dry run: would update ${this.outputPath}`);
118+
} else {
119+
writeProtoFile(finalMessages, finalEnums, this.outputPath);
120+
logger.info(`Updated: ${this.outputPath}`);
121+
}
122+
}
123+
124+
/**
125+
* Get the merge reporter for accessing change reports.
126+
*/
127+
getReporter(): MergeReporter {
128+
return this.reporter;
113129
}
114130
}
115131

@@ -124,13 +140,17 @@ if (require.main === module) {
124140
.argParser((val: string) => val.split(',').map(s => s.trim()))
125141
.default(['protos/generated/models/aggregated_models.proto', 'protos/generated/services/default_service.proto']))
126142
.addOption(new Option('-o, --output <path>', 'output proto file').default('protos/schemas/common.proto'))
143+
.addOption(new Option('-r, --report <path>', 'output merge report (markdown)'))
144+
.addOption(new Option('-d, --dry-run', 'preview changes without writing output file').default(false))
127145
.allowExcessArguments(false)
128146
.parse();
129147

130148
type BackwardCompatOpts = {
131149
existing: string;
132150
incoming: string[];
133151
output: string;
152+
report?: string;
153+
dryRun: boolean;
134154
};
135155

136156
const opts = command.opts() as BackwardCompatOpts;
@@ -146,17 +166,30 @@ if (require.main === module) {
146166
process.exit(1);
147167
}
148168

169+
const writer = new BackwardCompatibleWriter(
170+
opts.existing,
171+
opts.incoming,
172+
opts.output
173+
);
174+
149175
try {
150-
const writer = new BackwardCompatibleWriter(
151-
opts.existing,
152-
opts.incoming,
153-
opts.output
154-
);
155-
writer.process();
176+
writer.process(opts.dryRun);
156177
} catch (error) {
178+
// Write report even on error
179+
if (opts.report) {
180+
writeFileSync(opts.report, writer.getReporter().toMarkdown());
181+
logger.info(`Report written: ${opts.report}`);
182+
}
183+
157184
if (error instanceof BackwardCompatibilityError) {
158185
process.exit(1);
159186
}
160187
throw error;
161188
}
189+
190+
// Write report on success
191+
if (opts.report) {
192+
writeFileSync(opts.report, writer.getReporter().toMarkdown());
193+
logger.info(`Report written: ${opts.report}`);
194+
}
162195
}

tools/proto-convert/src/postprocessing/CompatibilityMerger.ts

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ProtoOneof,
1111
Annotation
1212
} from './types';
13+
import { MergeReporter, formatField } from './MergeReporter';
1314

1415
const DEPRECATED: Annotation = { name: 'deprecated', value: 'true' };
1516

@@ -55,20 +56,25 @@ function addDeprecated<T extends HasAnnotations>(item: T): T {
5556
}
5657

5758
/**
58-
* Check if optional added or removed. If so, push error and return true
59+
* Check if optional modifier changed and report it.
5960
*/
60-
function hasOptionalError(
61+
function checkOptionalChange(
6162
source: ProtoField,
6263
upcoming: ProtoField,
6364
msgName: string,
64-
errors: string[]
65+
reporter?: MergeReporter
6566
): boolean {
6667
const sourceOptional = source.modifier === 'optional';
6768
const upcomingOptional = upcoming.modifier === 'optional';
6869

6970
if (sourceOptional !== upcomingOptional) {
70-
const change = sourceOptional ? 'removed' : 'added';
71-
errors.push(`${msgName}.${source.name}: optional ${change}`);
71+
reporter?.addFieldChange({
72+
messageName: msgName,
73+
changeType: 'optional_error',
74+
fieldName: source.name,
75+
existingType: formatField(source),
76+
incomingType: formatField(upcoming)
77+
});
7278
return true;
7379
}
7480
return false;
@@ -88,7 +94,7 @@ function mergeField(
8894
sourceField: ProtoField,
8995
upcomingMap: Map<string, ProtoField>,
9096
msgName: string,
91-
errors: string[]
97+
reporter?: MergeReporter
9298
): ProtoField {
9399
if (isDeprecated(sourceField)) {
94100
return sourceField;
@@ -100,30 +106,48 @@ function mergeField(
100106
if (upcomingField) {
101107
upcomingMap.delete(baseName);
102108

103-
if (hasOptionalError(sourceField, upcomingField, msgName, errors)) {
104-
return sourceField;
105-
}
109+
// Report optional modifier changes (but don't stop)
110+
checkOptionalChange(sourceField, upcomingField, msgName, reporter);
106111

107112
if (fieldsMatch(sourceField, upcomingField)) {
108113
return sourceField;
109114
} else {
110115
// Type or repeated change - deprecate and version
111116
const newName = `${baseName}_${getFieldVersion(sourceField.name) + 1}`;
112117
upcomingMap.set(newName, { ...upcomingField, name: newName });
118+
reporter?.addFieldChange({
119+
messageName: msgName,
120+
changeType: 'type_changed',
121+
fieldName: sourceField.name,
122+
existingType: formatField(sourceField),
123+
incomingType: formatField(upcomingField),
124+
versionedName: newName
125+
});
113126
return addDeprecated(sourceField);
114127
}
115128
} else {
129+
reporter?.addFieldChange({
130+
messageName: msgName,
131+
changeType: 'removed',
132+
fieldName: sourceField.name,
133+
existingType: formatField(sourceField)
134+
});
116135
return addDeprecated(sourceField);
117136
}
118137
}
119138

139+
/** Check if field name is a versioned name (ends with _N where N is a number) */
140+
function isVersionedName(name: string): boolean {
141+
return /_\d+$/.test(name);
142+
}
143+
120144
/**
121145
* Merge a source message with an upcoming message.
122146
*/
123147
export function mergeMessage(
124148
sourceMsg: ProtoMessage,
125149
upcomingMsg: ProtoMessage,
126-
errors: string[]
150+
reporter?: MergeReporter
127151
): ProtoMessage {
128152
const upcomingByName = new Map(upcomingMsg.fields.map(f => [f.name, f]));
129153

@@ -133,7 +157,7 @@ export function mergeMessage(
133157
// Process regular fields
134158
for (const sourceField of sourceMsg.fields) {
135159
maxFieldNumber = Math.max(maxFieldNumber, sourceField.number);
136-
mergedFields.push(mergeField(sourceField, upcomingByName, sourceMsg.name, errors));
160+
mergedFields.push(mergeField(sourceField, upcomingByName, sourceMsg.name, reporter));
137161
}
138162

139163
// Process oneofs
@@ -155,7 +179,7 @@ export function mergeMessage(
155179
const mergedOneofFields: ProtoField[] = [];
156180
for (const sourceField of sourceOneof.fields) {
157181
maxFieldNumber = Math.max(maxFieldNumber, sourceField.number);
158-
mergedOneofFields.push(mergeField(sourceField, upcomingOneofByName, sourceMsg.name, errors));
182+
mergedOneofFields.push(mergeField(sourceField, upcomingOneofByName, sourceMsg.name, reporter));
159183
}
160184

161185
mergedOneofs.push({ ...sourceOneof, fields: mergedOneofFields });
@@ -165,6 +189,14 @@ export function mergeMessage(
165189

166190
// Assign field max number to remaining fields.
167191
for (const field of upcomingByName.values()) {
192+
if (!isVersionedName(field.name)) {
193+
reporter?.addFieldChange({
194+
messageName: sourceMsg.name,
195+
changeType: 'added',
196+
fieldName: field.name,
197+
incomingType: formatField(field)
198+
});
199+
}
168200
mergedFields.push({ ...field, number: ++maxFieldNumber });
169201
}
170202

@@ -174,6 +206,14 @@ export function mergeMessage(
174206
const remaining = oneofMaps.get(oneof.name);
175207
if (remaining) {
176208
for (const field of remaining.values()) {
209+
if (!isVersionedName(field.name)) {
210+
reporter?.addFieldChange({
211+
messageName: sourceMsg.name,
212+
changeType: 'added',
213+
fieldName: `${oneof.name}.${field.name}`,
214+
incomingType: formatField(field)
215+
});
216+
}
177217
oneof.fields.push({ ...field, number: ++maxFieldNumber });
178218
}
179219
}
@@ -192,7 +232,8 @@ export function mergeMessage(
192232
*/
193233
export function mergeEnum(
194234
sourceEnum: ProtoEnum,
195-
upcomingEnum: ProtoEnum
235+
upcomingEnum: ProtoEnum,
236+
reporter?: MergeReporter
196237
): ProtoEnum {
197238
const sourceValueMap = new Map(sourceEnum.values.map(v => [v.name, v]));
198239
const upcomingValueMap = new Map(upcomingEnum.values.map(v => [v.name, v]));
@@ -208,12 +249,22 @@ export function mergeEnum(
208249
if (upcomingValue) {
209250
mergedValues.push(sourceValue);
210251
} else {
252+
reporter?.addEnumChange({
253+
enumName: sourceEnum.name,
254+
changeType: 'removed',
255+
valueName: sourceValue.name
256+
});
211257
mergedValues.push(addDeprecated(sourceValue));
212258
}
213259
}
214260

215261
for (const [valueName, upcomingValue] of upcomingValueMap) {
216262
if (!sourceValueMap.has(valueName)) {
263+
reporter?.addEnumChange({
264+
enumName: sourceEnum.name,
265+
changeType: 'added',
266+
valueName: valueName
267+
});
217268
mergedValues.push({
218269
...upcomingValue,
219270
number: ++maxValueNumber

0 commit comments

Comments
 (0)