Skip to content

Commit d0ea186

Browse files
harcheclaude
andcommitted
Update API types to match current agentic.openshift.io CRDs
- Add `make generate-types` / `hack/generate-types.py` to auto-generate TypeScript types from live CRD schemas (proposals, proposalapprovals, approvalpolicies, analysisresults) - Rewrite proposal.ts to use generated types instead of hand-written ones - Add ProposalApproval and ApprovalPolicy models and GVK constants - Add derivePhase() to compute phase from status.conditions (CRD has no status.phase field) - Rewrite useApprovalActions to patch ProposalApproval stages instead of Proposal status.phase - Add useProposalApprovals hook for watching ProposalApproval resources - Update all components to use derivePhase() instead of status.phase - Update DecisionActions to find and patch ProposalApproval for the matching proposal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9778f4f commit d0ea186

29 files changed

Lines changed: 1671 additions & 1682 deletions

Makefile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.PHONY: generate-types build test lint
2+
3+
generate-types:
4+
python3 hack/generate-types.py
5+
6+
build:
7+
yarn build
8+
9+
test:
10+
yarn test
11+
12+
lint:
13+
yarn lint

hack/generate-types.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
#!/usr/bin/env python3
2+
"""Generate TypeScript types from Kubernetes CRD OpenAPI schemas.
3+
4+
Usage:
5+
python3 hack/generate-types.py # from live cluster
6+
python3 hack/generate-types.py --from-dir crds/ # from local CRD YAML files
7+
8+
Generates src/models/generated/*.ts with TypeScript interfaces matching
9+
the CRD spec and status schemas.
10+
"""
11+
12+
import json
13+
import os
14+
import subprocess
15+
import sys
16+
import textwrap
17+
from pathlib import Path
18+
19+
CRDS = {
20+
"proposals.agentic.openshift.io": "Proposal",
21+
"proposalapprovals.agentic.openshift.io": "ProposalApproval",
22+
"approvalpolicies.agentic.openshift.io": "ApprovalPolicy",
23+
"analysisresults.agentic.openshift.io": "AnalysisResult",
24+
}
25+
26+
OUT_DIR = Path(__file__).resolve().parent.parent / "src" / "models" / "generated"
27+
BANNER = "// Auto-generated from CRD — do not edit manually.\n// Regenerate with: make generate-types\n"
28+
29+
30+
def extract_from_cluster() -> dict[str, dict]:
31+
schemas = {}
32+
for crd_name in CRDS:
33+
print(f" Extracting {crd_name}...")
34+
result = subprocess.run(
35+
["oc", "get", "crd", crd_name, "-o",
36+
"jsonpath={.spec.versions[0].schema.openAPIV3Schema}"],
37+
capture_output=True, text=True, check=True,
38+
)
39+
schemas[crd_name] = json.loads(result.stdout)
40+
return schemas
41+
42+
43+
def extract_from_dir(d: str) -> dict[str, dict]:
44+
import yaml
45+
schemas = {}
46+
for crd_name in CRDS:
47+
for f in Path(d).glob("*.yaml"):
48+
with open(f) as fh:
49+
doc = yaml.safe_load(fh)
50+
if doc and doc.get("metadata", {}).get("name") == crd_name:
51+
schemas[crd_name] = doc["spec"]["versions"][0]["schema"]["openAPIV3Schema"]
52+
print(f" {f.name} -> {crd_name}")
53+
break
54+
else:
55+
print(f" WARNING: {crd_name} not found in {d}")
56+
return schemas
57+
58+
59+
def schema_to_ts(schema: dict, indent: int = 0) -> str:
60+
"""Convert an OpenAPI schema object to a TypeScript type string."""
61+
pad = " " * indent
62+
63+
if "x-kubernetes-preserve-unknown-fields" in schema:
64+
return "Record<string, unknown>"
65+
66+
typ = schema.get("type", "object")
67+
68+
if "enum" in schema:
69+
return " | ".join(f"'{v}'" for v in schema["enum"])
70+
71+
if typ == "string":
72+
fmt = schema.get("format", "")
73+
if fmt in ("date-time", "date"):
74+
return "string"
75+
return "string"
76+
77+
if typ == "integer":
78+
return "number"
79+
80+
if typ == "number":
81+
return "number"
82+
83+
if typ == "boolean":
84+
return "boolean"
85+
86+
if typ == "array":
87+
items = schema.get("items", {})
88+
item_type = schema_to_ts(items, indent)
89+
return f"({item_type})[]"
90+
91+
if typ == "object":
92+
props = schema.get("properties", {})
93+
if not props:
94+
additional = schema.get("additionalProperties")
95+
if additional and isinstance(additional, dict):
96+
val_type = schema_to_ts(additional, indent)
97+
return f"Record<string, {val_type}>"
98+
return "Record<string, unknown>"
99+
100+
required = set(schema.get("required", []))
101+
lines = ["{"]
102+
for name, prop_schema in sorted(props.items()):
103+
desc = prop_schema.get("description", "")
104+
optional = "?" if name not in required else ""
105+
prop_type = schema_to_ts(prop_schema, indent + 1)
106+
if desc:
107+
short = desc.replace("\n", " ").strip()
108+
if len(short) > 100:
109+
short = short[:97] + "..."
110+
lines.append(f"{pad} /** {short} */")
111+
lines.append(f"{pad} {name}{optional}: {prop_type};")
112+
lines.append(f"{pad}}}")
113+
return "\n".join(lines)
114+
115+
return "unknown"
116+
117+
118+
def generate_one(crd_name: str, type_name: str, schema: dict) -> str:
119+
"""Generate TypeScript for a single CRD."""
120+
lines = [BANNER, ""]
121+
122+
props = schema.get("properties", {})
123+
spec_schema = props.get("spec", {})
124+
status_schema = props.get("status", {})
125+
126+
# Generate Spec type
127+
if spec_schema.get("properties"):
128+
spec_ts = schema_to_ts(spec_schema, 0)
129+
lines.append(f"export type {type_name}Spec = {spec_ts};\n")
130+
131+
# Generate Status type
132+
if status_schema.get("properties"):
133+
status_ts = schema_to_ts(status_schema, 0)
134+
lines.append(f"export type {type_name}Status = {status_ts};\n")
135+
136+
# Generate nested types that are reusable (conditions, steps, etc.)
137+
# Extract any deeply nested object types that appear in spec or status
138+
139+
return "\n".join(lines)
140+
141+
142+
def main():
143+
if len(sys.argv) > 2 and sys.argv[1] == "--from-dir":
144+
schemas = extract_from_dir(sys.argv[2])
145+
else:
146+
print("Extracting CRD schemas from cluster...")
147+
schemas = extract_from_cluster()
148+
149+
OUT_DIR.mkdir(parents=True, exist_ok=True)
150+
151+
for crd_name, schema in schemas.items():
152+
type_name = CRDS[crd_name]
153+
out_file = OUT_DIR / f"{crd_name.split('.')[0]}.ts"
154+
print(f" Generating {out_file.name} ({type_name})...")
155+
156+
ts_code = generate_one(crd_name, type_name, schema)
157+
out_file.write_text(ts_code)
158+
159+
# Barrel export
160+
barrel = OUT_DIR / "index.ts"
161+
barrel_lines = [BANNER, ""]
162+
for crd_name in schemas:
163+
base = crd_name.split(".")[0]
164+
barrel_lines.append(f"export * from './{base}';")
165+
barrel_lines.append("")
166+
barrel.write_text("\n".join(barrel_lines))
167+
168+
print(f"Done. Generated types in {OUT_DIR}")
169+
170+
171+
if __name__ == "__main__":
172+
main()

src/__tests__/proposal.test.ts

Lines changed: 0 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import {
22
getPhaseDisplay,
33
getRiskColor,
4-
getAnalysisData,
54
getReadinessSummary,
65
getFindings,
76
getOlmOperatorStatus,
87
sortFindings,
98
COMPONENT_TYPES,
10-
LightspeedProposal,
119
AdapterComponent,
1210
OtaReadinessSummary,
1311
OtaFinding,
@@ -52,78 +50,6 @@ describe('getRiskColor', () => {
5250
});
5351
});
5452

55-
describe('getAnalysisData', () => {
56-
const makeProposal = (overrides?: Partial<LightspeedProposal['status']>): LightspeedProposal =>
57-
({
58-
spec: { request: 'test', workflow: 'ota-advisory' },
59-
status: overrides,
60-
}) as LightspeedProposal;
61-
62-
it('returns undefined option when no status', () => {
63-
const result = getAnalysisData(makeProposal());
64-
expect(result.analysis).toBeUndefined();
65-
expect(result.option).toBeUndefined();
66-
expect(result.components).toEqual([]);
67-
});
68-
69-
it('returns the first option by default', () => {
70-
const option = {
71-
title: 'Option A',
72-
summary: 'first',
73-
diagnosis: { summary: '', confidence: '', rootCause: '' },
74-
proposal: { description: '', actions: [], risk: 'low', reversible: true },
75-
components: [{ type: 'custom', data: 1 }],
76-
};
77-
const result = getAnalysisData(
78-
makeProposal({
79-
steps: { analysis: { options: [option] } },
80-
}),
81-
);
82-
expect(result.option).toBe(option);
83-
expect(result.components).toEqual(option.components);
84-
});
85-
86-
it('respects selectedOption index', () => {
87-
const optionA = {
88-
title: 'A',
89-
summary: '',
90-
diagnosis: { summary: '', confidence: '', rootCause: '' },
91-
proposal: { description: '', actions: [], risk: 'low', reversible: true },
92-
components: [{ type: 'a' }],
93-
};
94-
const optionB = {
95-
title: 'B',
96-
summary: '',
97-
diagnosis: { summary: '', confidence: '', rootCause: '' },
98-
proposal: { description: '', actions: [], risk: 'low', reversible: true },
99-
components: [{ type: 'b' }],
100-
};
101-
const result = getAnalysisData(
102-
makeProposal({
103-
steps: { analysis: { selectedOption: 1, options: [optionA, optionB] } },
104-
}),
105-
);
106-
expect(result.option).toBe(optionB);
107-
expect(result.components).toEqual(optionB.components);
108-
});
109-
110-
it('falls back to analysis-level components when option has none', () => {
111-
const option = {
112-
title: 'A',
113-
summary: '',
114-
diagnosis: { summary: '', confidence: '', rootCause: '' },
115-
proposal: { description: '', actions: [], risk: 'low', reversible: true },
116-
};
117-
const analysisComponents: AdapterComponent[] = [{ type: 'fallback' }];
118-
const result = getAnalysisData(
119-
makeProposal({
120-
steps: { analysis: { options: [option], components: analysisComponents } },
121-
}),
122-
);
123-
expect(result.components).toEqual(analysisComponents);
124-
});
125-
});
126-
12753
describe('component extractors', () => {
12854
const readiness: OtaReadinessSummary = {
12955
type: COMPONENT_TYPES.readinessSummary,

src/components/ClusterUpdatePage.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import {
1515
} from '@patternfly/react-core';
1616
import { useClusterVersion } from '../hooks/useClusterVersion';
1717
import { useUpdateProposals } from '../hooks/useUpdateProposals';
18-
import { I18N_NAMESPACE, LABELS, isTerminalPhase } from '../utils/constants';
19-
import { LightspeedProposal } from '../models/proposal';
18+
import { I18N_NAMESPACE, LABELS, TERMINAL_PHASES } from '../utils/constants';
19+
import { LightspeedProposal, derivePhase } from '../models/proposal';
2020
import { ClusterVersion } from '../models/clusterversion';
2121
import UpdatePlanTab from './update-plan/UpdatePlanTab';
2222
import ActivePlansTab from './active-plans/ActivePlansTab';
@@ -40,10 +40,9 @@ export default function ClusterUpdatePage() {
4040
);
4141

4242
const activePlans = React.useMemo(
43-
() => proposals.filter((p: LightspeedProposal) => !isTerminalPhase(p.status?.phase)),
43+
() => proposals.filter((p: LightspeedProposal) => !TERMINAL_PHASES.has(derivePhase(p))),
4444
[proposals],
4545
);
46-
const activeProposal = activePlans[0];
4746

4847
const pageTitle = t('Cluster Update');
4948
const loading = !cvLoaded;
@@ -92,8 +91,7 @@ export default function ClusterUpdatePage() {
9291
{activeTab === 0 && (
9392
<UpdatePlanTab
9493
clusterVersion={clusterVersion as ClusterVersion}
95-
proposals={proposals as LightspeedProposal[]}
96-
activeProposal={activeProposal}
94+
proposals={proposals}
9795
/>
9896
)}
9997
</TabContentBody>

src/components/active-plans/ActivePlansTab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Timestamp } from '@openshift-console/dynamic-plugin-sdk';
55
import { EmptyState, EmptyStateBody } from '@patternfly/react-core';
66
import { SearchIcon } from '@patternfly/react-icons';
77
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
8-
import { LightspeedProposal } from '../../models/proposal';
8+
import { LightspeedProposal, derivePhase } from '../../models/proposal';
99
import { I18N_NAMESPACE, LABELS } from '../../utils/constants';
1010
import PhaseLabel from '../shared/PhaseLabel';
1111
import './active-plans.css';
@@ -54,7 +54,7 @@ const ActivePlansTab: React.FC<ActivePlansTabProps> = ({ activePlans }) => {
5454
</Td>
5555
<Td dataLabel={t('Target Version')}>{targetVersion}</Td>
5656
<Td dataLabel={t('Phase')}>
57-
<PhaseLabel phase={proposal.status?.phase} />
57+
<PhaseLabel phase={derivePhase(proposal)} />
5858
</Td>
5959
<Td dataLabel={t('Update Type')}>{updateType}</Td>
6060
<Td dataLabel={t('Age')}>

src/components/shared/PhaseIcon.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ const PhaseIcon: React.FC<PhaseIconProps> = ({ phase }) => {
3131
<SearchIcon />
3232
</Icon>
3333
);
34+
case 'Analysed':
35+
return (
36+
<Icon status="success">
37+
<CheckCircleIcon />
38+
</Icon>
39+
);
3440
case 'Proposed':
3541
return (
3642
<Icon status="warning">

0 commit comments

Comments
 (0)