diff --git a/.github/workflows/deploy-Dataspace.yml b/.github/workflows/deploy-Dataspace.yml index 6d769c6a..d58d89b6 100644 --- a/.github/workflows/deploy-Dataspace.yml +++ b/.github/workflows/deploy-Dataspace.yml @@ -182,21 +182,20 @@ jobs: # Production has no smoke-test job today, so its only quality gate is the # boot health check above. Mark this release good immediately so a FUTURE # deploy knows what to roll back to if it fails. - - name: Mark release as last-known-good (prod only) - if: github.ref_name == 'main' - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ vars.EC2_HOST }} - username: ${{ secrets.EC2_USERNAME }} - key: ${{ secrets.EC2_PRIVATE_KEY }} - script: echo "${{ steps.meta.outputs.release }}" > /home/ubuntu/DataExchange/releases/.last_good - smoke-tests: needs: build-and-deploy - if: github.ref_name == 'dev' uses: CivicDataLab/CivicDataSpace-test/.github/workflows/run-smoke.yml@CI + with: + # main: only the readonly allowlist ever runs against prod. + # dev: stage 1 of 2 -- functional-tests runs only if this passes. + suite: ${{ github.ref_name == 'main' && 'readonly' || 'smoke' }} + # Explicit: run-smoke's api job falls back to the dev API when this is + # unset, which would test dev's backend while gating a prod deploy. + api_base_url: ${{ github.ref_name == 'main' && 'https://api.datakeep.civicdays.in' || 'https://dev.api.civicdataspace.in' }} secrets: - HOME_URL_DEV: ${{ secrets.HOME_URL_DEV }} + # Literal, not a secret, so a missing value can't silently fall back to + # the dev site through the `&& ||` idiom. + HOME_URL_DEV: ${{ github.ref_name == 'main' && 'https://civicdataspace.in' || secrets.HOME_URL_DEV }} TEST_EMAIL_1: ${{ secrets.TEST_EMAIL_1 }} TEST_PASSWORD_1: ${{ secrets.TEST_PASSWORD_1 }} TEST_EMAIL_2: ${{ secrets.TEST_EMAIL_2 }} @@ -206,18 +205,66 @@ jobs: # and the job fails its preflight. KEYCLOAK_CLIENT_SECRET: ${{ secrets.KEYCLOAK_CLIENT_SECRET }} - # dev only: smoke tests are the real quality gate here. Only once they pass - # does this release become the thing a future rollback would target. - promote-dev: - needs: [build-and-deploy, smoke-tests] - if: github.ref_name == 'dev' && needs.smoke-tests.result == 'success' + functional-tests: + needs: smoke-tests + # dev only, stage 2: runs only once smoke has passed. Functional tests + # write (datasets, use cases), so they never run against prod. + if: github.ref_name != 'main' + uses: CivicDataLab/CivicDataSpace-test/.github/workflows/run-smoke.yml@CI + with: + suite: functional + api_base_url: https://dev.api.civicdataspace.in + # Provider functional is flaky against dev, so it runs report-only in + # provider-functional-report instead of gating the deploy. + skip_provider: true + secrets: + HOME_URL_DEV: ${{ secrets.HOME_URL_DEV }} + TEST_EMAIL_1: ${{ secrets.TEST_EMAIL_1 }} + TEST_PASSWORD_1: ${{ secrets.TEST_PASSWORD_1 }} + TEST_EMAIL_2: ${{ secrets.TEST_EMAIL_2 }} + TEST_PASSWORD_2: ${{ secrets.TEST_PASSWORD_2 }} + KEYCLOAK_CLIENT_SECRET: ${{ secrets.KEYCLOAK_CLIENT_SECRET }} + + provider-functional-report: + name: Provider Functional (report-only) + needs: smoke-tests + # Report-only: promote and rollback do not list this job in needs, so its + # result never rolls back or blocks a dev deploy. Promote it to a gate + # once the provider suite is stable against dev. + if: github.ref_name != 'main' + uses: CivicDataLab/CivicDataSpace-test/.github/workflows/run-smoke.yml@CI + with: + suite: functional + only_provider: true + api_base_url: https://dev.api.civicdataspace.in + secrets: + HOME_URL_DEV: ${{ secrets.HOME_URL_DEV }} + TEST_EMAIL_1: ${{ secrets.TEST_EMAIL_1 }} + TEST_PASSWORD_1: ${{ secrets.TEST_PASSWORD_1 }} + TEST_EMAIL_2: ${{ secrets.TEST_EMAIL_2 }} + TEST_PASSWORD_2: ${{ secrets.TEST_PASSWORD_2 }} + # org_add_permission needs a token to check canAdd; without this every + # org-create test skips on both workers and the report is hollow. + KEYCLOAK_CLIENT_SECRET: ${{ secrets.KEYCLOAK_CLIENT_SECRET }} + + # Only once the tests pass does this release become the thing a future + # rollback would target -- on prod too, which used to be marked + # last-known-good straight after deploy with no tests at all. + promote: + # provider-functional-report is listed so this waits for every test to + # finish, but its result is deliberately not checked (report-only). + needs: [build-and-deploy, smoke-tests, functional-tests, provider-functional-report] + # Explicit gating results, not success()/!failure(): on main the functional + # jobs are skipped by design, and the provider job must not block. + if: >- + ${{ !cancelled() && needs.build-and-deploy.result == 'success' && + needs.smoke-tests.result == 'success' && + (needs.functional-tests.result == 'success' || needs.functional-tests.result == 'skipped') }} runs-on: ubuntu-latest - # vars.EC2_HOST is an environment-scoped variable (Settings -> Environments - # -> development), only visible to jobs that declare `environment:`. - # Without this, appleboy/ssh-action fails with "missing server host" - # (found via a live run - build-and-deploy/smoke-tests both succeeded but - # this job still failed, which is why `if:` here is already gated to dev). - environment: development + # vars.EC2_HOST is an environment-scoped variable, only visible to jobs + # that declare `environment:` -- without it appleboy/ssh-action fails + # with "missing server host". + environment: ${{ github.ref_name == 'main' && 'production' || 'development' }} steps: - name: Mark this release as last-known-good uses: appleboy/ssh-action@v1.0.3 @@ -227,13 +274,18 @@ jobs: key: ${{ secrets.EC2_PRIVATE_KEY }} script: echo "${{ needs.build-and-deploy.outputs.release }}" > /home/ubuntu/DataExchange/releases/.last_good - rollback-dev: - needs: [build-and-deploy, smoke-tests] - if: github.ref_name == 'dev' && needs.smoke-tests.result == 'failure' + rollback: + # Waits for the report-only provider job too, without checking its result. + needs: [build-and-deploy, smoke-tests, functional-tests, provider-functional-report] + # Explicit gating results, not failure(): failure() would also count the + # report-only provider job. + if: >- + ${{ !cancelled() && needs.build-and-deploy.result == 'success' && + (needs.smoke-tests.result == 'failure' || needs.functional-tests.result == 'failure') }} runs-on: ubuntu-latest - # Same environment-scoped vars.EC2_HOST issue as promote-dev - see the + # Same environment-scoped vars.EC2_HOST issue as promote - see the # comment there. - environment: development + environment: ${{ github.ref_name == 'main' && 'production' || 'development' }} steps: - name: Revert to last known-good release uses: appleboy/ssh-action@v1.0.3 diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000..f68c08a0 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,47 @@ +# Full test suite against dev for every PR into main. Required by branch +# protection on main, so a PR merges (and prod deploys) only once it's green. +# dev already runs the PR's code (it was merged to dev first). +name: PR Gate + +on: + pull_request: + branches: [main] + +concurrency: + group: pr-gate-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + full-suite: + name: Full Suite (dev) + uses: CivicDataLab/CivicDataSpace-test/.github/workflows/run-smoke.yml@CI + with: + suite: full + # Provider is flaky against dev: it reports in provider-report below, which + # branch protection does not require. + skip_provider: true + api_base_url: https://dev.api.civicdataspace.in + secrets: + HOME_URL_DEV: ${{ secrets.HOME_URL_DEV }} + TEST_EMAIL_1: ${{ secrets.TEST_EMAIL_1 }} + TEST_PASSWORD_1: ${{ secrets.TEST_PASSWORD_1 }} + TEST_EMAIL_2: ${{ secrets.TEST_EMAIL_2 }} + TEST_PASSWORD_2: ${{ secrets.TEST_PASSWORD_2 }} + KEYCLOAK_CLIENT_SECRET: ${{ secrets.KEYCLOAK_CLIENT_SECRET }} + + provider-report: + name: Provider Full Suite (report-only) + uses: CivicDataLab/CivicDataSpace-test/.github/workflows/run-smoke.yml@CI + with: + suite: full + only_provider: true + api_base_url: https://dev.api.civicdataspace.in + secrets: + HOME_URL_DEV: ${{ secrets.HOME_URL_DEV }} + TEST_EMAIL_1: ${{ secrets.TEST_EMAIL_1 }} + TEST_PASSWORD_1: ${{ secrets.TEST_PASSWORD_1 }} + TEST_EMAIL_2: ${{ secrets.TEST_EMAIL_2 }} + TEST_PASSWORD_2: ${{ secrets.TEST_PASSWORD_2 }} + # org_add_permission needs a token to check canAdd; without this every + # org-create test skips on both workers and the report is hollow. + KEYCLOAK_CLIENT_SECRET: ${{ secrets.KEYCLOAK_CLIENT_SECRET }} diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/details/page.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/details/page.tsx index 579dc0fa..f4fa25f0 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/details/page.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/aimodels/edit/[id]/details/page.tsx @@ -18,6 +18,7 @@ import { } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { enumValues } from '@/lib/enumValues'; import RichTextEditor from '@/components/RichTextEditor/RichTextEditor'; import { useEditStatus } from '../../context'; @@ -170,17 +171,6 @@ const geographiesListQueryDoc = graphql(` } `); -const promptDomainEnumValuesQueryDoc = graphql(` - query PromptDomainEnum { - __type(name: "PromptDomain") { - enumValues { - name - description - } - } - } -`); - const FetchAIModelDetails = graphql(` query AIModelDetails($filters: AIModelFilter) { aiModels(filters: $filters) { @@ -297,11 +287,6 @@ export default function AIModelDetailsPage() { ) ); - const getPromptDomainEnumValues = - useQuery([`prompt_domain_enum_values_query`], () => - GraphQL(promptDomainEnumValuesQueryDoc, {}) - ); - const AIModelData = useQuery( [ `fetch_AIModelDetails`, @@ -477,16 +462,14 @@ export default function AIModelDetailsPage() { const domainOptions = [ { label: 'Click to select from dropdown', value: '' }, - ...( - getPromptDomainEnumValues.data?.__type?.enumValues?.map((item: { name: string }) => ({ - label: item.name - .toLowerCase() - .split('_') - .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '), - value: item.name, - })) || [] - ), + ...enumValues(PromptDomain).map((name) => ({ + label: name + .toLowerCase() + .split('_') + .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '), + value: name, + })), ]; const modelTypeOptions = [ diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditMetadata.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditMetadata.tsx index e258be5a..097f05b6 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditMetadata.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/components/EditMetadata.tsx @@ -5,6 +5,10 @@ import { useParams } from 'next/navigation'; import { graphql } from '@/gql'; import { MetadataModels, + PromptDomain, + PromptTaskType, + TargetLanguage, + TargetModelType, UpdateMetadataInput, UpdatePromptMetadataInput, } from '@/gql/generated/graphql'; @@ -21,6 +25,7 @@ import { } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { enumValues } from '@/lib/enumValues'; import { RichTextEditor } from '@/components/RichTextEditor'; import DatasetLoading from '../../../components/loading-dataset'; import { useDatasetEditStatus } from '../context'; @@ -113,54 +118,6 @@ const metadataQueryDoc = graphql(` } `); -// Introspection query to get PromptTaskType enum values from schema -const promptTaskTypeEnumQuery = graphql(` - query PromptTaskTypeEnum { - __type(name: "PromptTaskType") { - enumValues { - name - description - } - } - } -`); - -// Introspection query to get PromptDomain enum values from schema -const promptDomainEnumQuery = graphql(` - query PromptDomainEnum { - __type(name: "PromptDomain") { - enumValues { - name - description - } - } - } -`); - -// Introspection query to get TargetLanguage enum values from schema -const targetLanguageEnumQuery = graphql(` - query TargetLanguageEnum { - __type(name: "TargetLanguage") { - enumValues { - name - description - } - } - } -`); - -// Introspection query to get TargetModelType enum values from schema -const targetModelTypeEnumQuery = graphql(` - query TargetModelTypeEnum { - __type(name: "TargetModelType") { - enumValues { - name - description - } - } - } -`); - // Mutation to update prompt-specific metadata const updatePromptMetadataMutationDoc = graphql(` mutation UpdatePromptMetadata($updateInput: UpdatePromptMetadataInput!) { @@ -362,31 +319,6 @@ export function EditMetadata({ id }: { id: string }) { ) ); - // Fetch PromptTaskType enum values from GraphQL schema - const getPromptTaskTypeEnum = useQuery( - ['prompt_task_type_enum'], - () => GraphQL(promptTaskTypeEnumQuery), - { staleTime: Infinity } - ); - - const getPromptDomainEnum = useQuery( - ['prompt_domain_enum'], - () => GraphQL(promptDomainEnumQuery), - { staleTime: Infinity } - ); - - const getTargetLanguageEnum = useQuery( - ['target_language_enum'], - () => GraphQL(targetLanguageEnumQuery), - { staleTime: Infinity } - ); - - const getTargetModelTypeEnum = useQuery( - ['target_model_type_enum'], - () => GraphQL(targetModelTypeEnumQuery), - { staleTime: Infinity } - ); - const [isTagsListUpdated, setIsTagsListUpdated] = useState(false); // State for prompt metadata fields @@ -975,14 +907,12 @@ export function EditMetadata({ id }: { id: string }) { label="Task Type" displaySelected list={ - getPromptTaskTypeEnum.data?.__type?.enumValues?.map( - (enumValue) => ({ - label: enumValue.name - .replace(/_/g, ' ') - .replace(/\b\w/g, (c: string) => c.toUpperCase()), - value: enumValue.name, - }) - ) || [] + enumValues(PromptTaskType).map((name) => ({ + label: name + .replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase()), + value: name, + })) } selectedValue={ promptMetadataState.taskType @@ -1010,14 +940,12 @@ export function EditMetadata({ id }: { id: string }) { label="Domain" displaySelected list={ - getPromptDomainEnum.data?.__type?.enumValues?.map( - (enumValue) => ({ - label: enumValue.name - .replace(/_/g, ' ') - .replace(/\b\w/g, (c: string) => c.toUpperCase()), - value: enumValue.name, - }) - ) || [] + enumValues(PromptDomain).map((name) => ({ + label: name + .replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase()), + value: name, + })) } selectedValue={ promptMetadataState.domain @@ -1046,14 +974,12 @@ export function EditMetadata({ id }: { id: string }) { displaySelected creatable list={ - getTargetLanguageEnum.data?.__type?.enumValues?.map( - (enumValue) => ({ - label: enumValue.name - .replace(/_/g, ' ') - .replace(/\b\w/g, (c: string) => c.toUpperCase()), - value: enumValue.name, - }) - ) || [] + enumValues(TargetLanguage).map((name) => ({ + label: name + .replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase()), + value: name, + })) } selectedValue={ promptMetadataState.targetLanguages?.map( @@ -1078,14 +1004,12 @@ export function EditMetadata({ id }: { id: string }) { displaySelected creatable list={ - getTargetModelTypeEnum.data?.__type?.enumValues?.map( - (enumValue) => ({ - label: enumValue.name - .replace(/_/g, ' ') - .replace(/\b\w/g, (c: string) => c.toUpperCase()), - value: enumValue.name, - }) - ) || [] + enumValues(TargetModelType).map((name) => ({ + label: name + .replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase()), + value: name, + })) } selectedValue={ promptMetadataState.targetModelTypes?.map( diff --git a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx index 28a15474..2ba88ffc 100644 --- a/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx +++ b/app/[locale]/dashboard/[entityType]/[entitySlug]/dataset/[id]/edit/resources/components/EditResource.tsx @@ -3,6 +3,7 @@ import { useParams } from 'next/navigation'; import { graphql } from '@/gql'; import { CreateFileResourceInput, + PromptFormat, SchemaUpdateInput, UpdateFileResourceInput, UpdatePromptResourceInput, @@ -21,6 +22,7 @@ import { } from 'opub-ui'; import { GraphQL } from '@/lib/api'; +import { enumValues } from '@/lib/enumValues'; import { Loading } from '@/components/loading'; import PdfPreview from '../../../../../../../../(user)/components/PdfPreview'; import { useDatasetEditStatus } from '../../context'; @@ -93,18 +95,6 @@ const resourceDetails = graphql(` } `); -// Introspection query to get PromptFormat enum values from schema -const promptFormatEnumQuery = graphql(` - query PromptFormatEnumResource { - __type(name: "PromptFormat") { - enumValues { - name - description - } - } - } -`); - // Mutation to update prompt resource metadata const updatePromptResourceMutationDoc = graphql(` mutation UpdatePromptResource($updateInput: UpdatePromptResourceInput!) { @@ -407,23 +397,6 @@ export const EditResource = ({ } } - // Fetch PromptFormat enum values from GraphQL schema - const getPromptFormatEnum = useQuery( - ['prompt_format_enum_resource'], - () => GraphQL(promptFormatEnumQuery), - { staleTime: Infinity, enabled: isPromptDataset } - ); - - // Debug: Log enum data when it changes - React.useEffect(() => { - if (getPromptFormatEnum.data) { - console.log( - 'PromptFormat enum raw data:', - JSON.stringify(getPromptFormatEnum.data, null, 2) - ); - } - }, [getPromptFormatEnum.data]); - // Mutation for updating prompt resource metadata const updatePromptResourceMutation = useMutation( (data: { updateInput: UpdatePromptResourceInput }) => @@ -636,14 +609,12 @@ export const EditResource = ({ label="Prompt Format" displaySelected list={ - getPromptFormatEnum.data?.__type?.enumValues?.map( - (enumValue) => ({ - label: enumValue.name - .replace(/_/g, ' ') - .replace(/\b\w/g, (c: string) => c.toUpperCase()), - value: enumValue.name, - }) - ) || [] + enumValues(PromptFormat).map((name) => ({ + label: name + .replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase()), + value: name, + })) } selectedValue={promptFormat ? promptFormat : ''} onChange={(value) => { diff --git a/lib/enumValues.ts b/lib/enumValues.ts new file mode 100644 index 00000000..c12fd03c --- /dev/null +++ b/lib/enumValues.ts @@ -0,0 +1,8 @@ +/** + * Schema enums come from codegen, not from a runtime `__type` introspection + * query: the values are fixed at build time, and four introspection queries + * fired together on form load could be aborted by the browser, leaving the + * dropdowns permanently empty because the failure was cached. + */ +export const enumValues = (schemaEnum: Record): string[] => + Object.values(schemaEnum);